Skip to content

C++ 主循环可测性:通信边界接口注入与假实现 ​

周期性进程——控制器、采集器、通信转发器——的主循环总是绑着一圈外部 I/O:订阅传感器数据、下发目标指令、发布调试话题。这类进程的单元测试有一个共同困境:主循环逻辑与通信实现纠缠在一起,想覆盖断流、数据超时、力超限这些异常路径,似乎必须先有中间件环境和真实硬件。本文以一个 100 Hz 力控进程为例,给出一套可复用的可测性设计:把通信边界收拢成一个纯虚接口,读返回值附带数据时龄,再用直连仿真环境的假实现替代真实通信。控制回路与异常路径的全部测试都在纯进程内完成。适合写「长周期循环 + 外部 I/O」型进程的 C++ 开发者。

一、问题:主循环为什么测不动 ​

示例进程是一个 100 Hz 力控主循环,对外四路通信:订阅关节反馈、订阅六维力传感器、下发关节目标(动作通道,一个请求多次回报)、发布调试话题。每拍的处理流程是:读反馈 → 力误差解算 → 安全判定 → 下发目标。

测试困境来自三个地方:

  1. 通信内嵌。如果主循环直接持有中间件客户端句柄,构造进程就得先拉起中间件守护进程,测试环境依赖立刻变重。
  2. 异常路径难以注入。断流要真实断开连接,传感器失效要真实停发数据——这些在测试环境里很难按需触发,更难精确控制触发时机。
  3. 回调式接口难以编排。中间件普遍用回调投递数据,测试代码无法决定「这一拍读到什么」,时序相关的逻辑(比如断流检测)基本无法确定性验证。

二、做法 ​

通信边界收拢成一个纯虚接口 ​

不把订阅、发布的逻辑写进主循环,而是定义一个只描述「这一拍读写什么」的接口,主循环只认接口:

cpp
class TransportInterface
{
public:
    virtual Stamped<JointPoint> read_joint_state() = 0;   // 关节反馈
    virtual Stamped<Wrench> read_ft() = 0;                // 六维力
    virtual void write_joint_cmd(const std::vector<double>&) = 0;  // 目标下发
    virtual void publish_debug(const AppDebugSnapshot&) = 0;       // 调试发布
};

主循环类构造时以 std::unique_ptr<TransportInterface> 注入,自己不创建连接、不知道对面是谁。设计要点是接口方法只描述数据语义,不泄漏通道机制——签名里不出现话题名、通道类型、中间件类型,这些全部留在实现类里。

读返回值附带数据时龄 ​

Stamped<T> 在数据外附带 age_seconds(数据产生至今的秒数)。断流判断(时龄超过 200 ms 触发急停)收在应用侧统一做,带来两个直接好处:

  • 判定逻辑本身成为普通纯逻辑,单元测试直接可覆盖;
  • 假实现只要冻结数据时间戳,时龄就会随仿真时间自然增长,不需要物理断开任何连接。

假实现直连仿真环境 ​

测试用一个假实现替代真实通信:读方法返回虚拟实验台的状态,write_joint_cmd 把目标喂给实验台并推进一步仿真。主循环以为自己面对的是真实控制器,实际上整个力控回路都跑在纯进程内。

异常路径靠开关精确注入。断流模拟的实现:

cpp
// 假实现里的断流开关:冻结后时龄随拍增长,20 拍后越过 200ms 阈值
bool freeze_ft_ = false;
Stamped<Wrench> read_ft() override
{
    if (!freeze_ft_)
    {
        cached_ft_ = bench_->ft();
        cached_ft_time_ = sim_time_;
    }
    return {cached_ft_, sim_time_ - cached_ft_time_};
}

三、实际收益 ​

  • 回路测试零环境依赖:恒力静压 15 N 场景的稳态力 14.06 N、压深 9.4 mm 等 7 个用例全部纯进程内跑完,不需要中间件守护进程和网络;
  • 异常路径确定性触发:断流测试就是置位 freeze_ft_ 后步进 30 拍,断言状态机进入 FAULTED——什么时候断、断多久,全部由测试代码控制;
  • 更换中间件零改动:接真实中间件时只换一个实现类,主循环构造参数换个对象即可,接口不变。

四、一个所有权错误 ​

依赖注入时所有权必须唯一。一个真实出现过的错误:测试里用裸指针现场包了第二个 std::unique_ptr(std::unique_ptr<BenchIO>(io_raw)),两个 owner 管理同一块内存,测试收尾时 double free。

正确做法:接口对象 std::move 进主循环类,它是唯一 owner;测试侧保留裸指针,只用来观察状态和扳动开关,不参与生命周期管理。

五、适用判断 ​

任何「长周期循环 + 外部 I/O」的进程都适合这样切分:接口只描述每拍读写什么,假实现顺便承担仿真环境的适配层,一套装配既测业务逻辑也测异常路径。前提是接口方法的设计贴着主循环的自然节拍走——按拍读写的模型,比回调式接口容易构造假实现得多;如果接口本身是回调式的,时序控制在测试侧就先丢了一半。

归纳成三条:通信边界收拢成按拍读写的纯虚接口;数据时龄进返回值,让断流判定可测可注入;假实现兼任仿真适配层,主循环对真实环境零感知。做到这三条,主循环的全部行为——包括最难的异常路径——都能在无硬件环境下验证。

最近更新

基于 VitePress 构建